在上一篇中,利用了 Tensorflow estimator API 建立了一個 Logistic Regression 分類器。但是相信大家還是一頭霧水,這個分類器到底為我們做了些什麼事情 。今天以及隨後陸續的幾篇文章,我們將對上一篇的原始碼做更詳盡的解說,以及了解進行一個機械學習的訓練該有的基礎觀念。
就像準備應試學生,拿著考古題訓練自己對該學科的理解能力一樣。準備考試的學生,通常會先將考古題分成兩部分:一部分可以藉由邊做題目邊檢查解答,以糾正自己理解錯誤的地方。另外一部分的試題,則會先將解答覆蓋起來,待做完所有這部分的試題後,再檢查解答,用此方法評估自己的學習成果。
相同地,機械學習演算法,在訓練時,也會將現有的訓練資料分成兩個部分:第一個部分被稱為 training set,使用於模型的訓練過程中。在 training set 中每一個訓練實例的正確分類標籤,都會被納入在訓練的過程裡。第二個部分則被稱為 validation set,主要是用來評估訓練的結果。在這個過程中,已訓練好的模型將不會對 validation set 繼續訓練,相對地,模型將會一一預測在 validation set 的實例,最後再以適合評估該訓練任務的 metrics 對 validation set 做模型評估(Model Evaluation)。
在分類任務中常用的 metrics 就是分類的正確率,又稱為 accuracy,主要是計算分類正確的比例。
Model evaluation 主要的功能是防止模型過度學習,就好比應試的學生,將考古題內的所有答案,死記起來,反而無法將學到的知識應用在尚未見過的題目裡。同樣地,我們也希望透過機械學習完成訓練的模型,有足夠的能力將學到的規則,推論到尚未見到的例子中,這在文獻中又被稱為模型 generalization 的能力。
如何將資料分成訓練和評估資料,則有很多不同的方法。其中一種最常用的被稱為 Cross-Validation。Cross-Validation 將所有的訓練資料,分成 K 等份,其中 K - 1 的訓練資料會在訓練模型時使用,也就是作為 training set。而留下的一份,則在評估中使用,也就是 validation set。如此循環 K 次以確保所有的資料都有機會選進 validation set。
圖一:簡單的表達 5-fold cross-validation 該如何進行。圖中的綠色部分為 validation set,而白色部分為 training set。
在過去資料不足的情況下,如鐵達尼的資料集,其資料量不到一千的筆數,會使用一種叫做 Jack-Knife 的方法,在這個方法中一次只用一個訓練例子,當作評估例子,循環訓練的次數則與訓練資料集的大小相等(在 scikit-learn 中則被稱為 Leave-One-Out)。然而現在的資料及數量大多充足,所以選定 K 為 5 或 10 通常都已足夠。K 的選擇,最終還是要看訓練集內資料的分佈程度。如果資料分布過於 diverse,則偏好較大的 K 值,以確保 training set 內的資料能捕捉原資料分布的 diversity。
為了說明方便,這裡採用訓練類神經網路常用的模型評估的方式,也就是將訓練資料單純的分成兩份:一份是 training set,另外一份是 evaluation set。訓練類神經網路較少使用 k-fold cross validation 的原因在於:訓練類神經網路通常相當耗時,所以 cross-validation 這樣的方法比較不適合評估類神經網路。
接下來就是特徵工程的部分。特徵工程包括了資料清洗,資料前置處理,和特徵選擇三個步驟。資料清洗,在英文中被稱為 data cleaning。在上篇中我們先對 ‘Age’ 這一欄位裡的遺失資料(missing value),填進一個任意數值就是進行資料清洗的工作。至於填入什麼數值,端看遺失資料的分佈情況,和該欄位對預測的重要性。因為 ‘Age ’這個欄位的數值遺失狀況,不是很嚴重,且鐵達尼資料的訓練數量非常少量,所以將遺失資料剔除,並不是很好的方法。相較而言,填入 0,這一個在年齡中,不可能出現的數值,有能代表資料上的缺失的情況。
以下程式碼,就是利用 pandas package 讀入 csv 格式的訓練資料後,將 'Age' 欄位遺失的部分填上零的數字。
import pandas as pd
from sklearn.model_seleciton import train_test_split
data_frame = pd.read_csv('../input/train.csv')
train_set, valid_set = train_test_split(data_frame.index, test_size=0.01)
data_frame.loc[:, 'Age'] = data_frame.loc[:, 'Age'].fillna(0)
因為有了兩個不同的資料集,現在我們需要兩個 input function,一個是給 LinearClassifier 物件呼叫 train 方法時使用(train_input),而另外一個則是呼叫 evaluate 方法使用(eval_input)。
from sklearn.preprocessing import MinMaxScaler
from functools import partial
def train_input(features, labels):
source = tf.data.Dataset.from_tensor_slices((features, labels))
return source.batch(10).repeat()
def eval_input(features, labels, text, vectorizer, selector):
vectorized_ = vectorizer.transform(text)
selected_ = selector.transform(vectorized_)
features['PersonInfo'] = (selected_.toarray() + 1).astype(np.float32)
source = tf.data.Dataset.from_tensors((features, labels))
return source
其實是資料前置處理的工作,此工作包含了對特徵座標準化和重新編碼等。一般的特徵,可就其值,分為特徵是連續數值分佈,或有限且離散分佈。連續數值分佈的特徵如 'Fare',通常所進行的前處理是對數值做標準化,或壓縮原數值在有界的數值範圍間。標準化有時又被稱為正規化,在 tensorflow 的 feature_column 則藉由提供一個 normalization function (見 normalizer_fn 引數)來達成,可以見下面的原始碼範例:
from sklearn.preprocessing import MinMaxScaler
normalizer = MinMaxScaler()
normalizer.fit(data_frame.loc[train_set, ['Fare', 'Age']])
# numerical features
feature_columns = []
feature_columns.append(
tf.feature_column.numeric_column('Fare', normalizer_fn=lambda x: x*normalizer.scale_[0]))
feature_columns.append(
tf.feature_column.numeric_column('Age', normalizer_fn=lambda x: x*normalizer.scale_[1]))
標準化或正規化的方式,在往後提到梯度下降最佳化演算法的時候,才會對這一個部分做詳細介紹。
對於有限且離散分佈的特徵,在英文中又被稱為 categorical feature。他們的值有些是文字,如 “Sex” 欄位,所以必須提供編碼的字彙集(vocabulary_list)來將此欄位轉換成 Logistic regression 可以接受的矩陣型態。編碼的方式是用所謂的 one-hot encoding。在這個編碼方式下產生的矩陣,有著與字彙集大小相等的行數,矩陣每一行都代表一個在字彙集中的值。
上圖簡單表示當 Sex 欄位用 one-hot encoding 後的表示。圖左方是 Sex 欄位的字彙集,右方則是相對應的 one-hot encoding 方式。可以看到,編碼矩陣的行數和字彙集大小相同,為二。每一個例子只能有一行元素其值為 1,而其所在行,代表該訓練例子的 Sex 欄位中是 Female 或 Male。
除了將每一個可能出現的值列舉出來外,做 one-hot encoding 外,另外一個方法則是根據數值的分佈情況,從原來的字彙集中選取代表性高的數個值,來建構新的字彙集。通常這一個方法適合原字彙集數量較大,或其分佈有過度集中在少數幾個值的情況。在 Parch (一等親同在船上的數量)和 SibSp (二等親同在船上的數量),就是這種情況。
對於此種情況,我們可以根據值的分佈,建立新的 bucket,並對這些特徵重新分配新值。(見 bucketized_column 的 boundaries 引數和 categorical_column_with_identity 的 num_buckets 引數)
# categorical features
feature_columns.append(tf.feature_column.indicator_column(
tf.feature_column.categorical_column_with_identity('Pclass', num_buckets=4)))
feature_columns.append(tf.feature_column.bucketized_column(
source_column=tf.feature_column.numeric_column('SibSp'),
boundaries=[1, 2, 4]))
feature_columns.append(tf.feature_column.bucketized_column(
source_column=tf.feature_column.numeric_column('Parch'),
boundaries=[1, 2, 3]))
feature_columns.append(tf.feature_column.indicator_column(
tf.feature_column.categorical_column_with_vocabulary_list(
'Sex', vocabulary_list=['female', 'male'])))
而字彙集數量過大的情況,則多出現在原資料中文字的欄位裡,如 Name 欄位中,除了名字外還包括了稱謂。文字欄位的處理,會仰賴一種叫做 Bag of Word 的 vectorization 方法。在下面的原始碼中,我們利用 scikit-learn 的 CountVectorizer 來對 Name 和 Ticket 欄位來做 vectorization。
from sklearn.feature_extraction.text import CountVectorizer
desc = data_frame.loc[train_set, 'Name'].str.cat(
data_frame.loc[train_set, 'Ticket'], sep=' ').values
vectorizer = CountVectorizer()
vectorized_ = vectorizer.fit_transform(desc)
print(len(vectorizer.vocabulary_))
# 2186
可以看到如果沒有做處理,直接 vectorization 的特徵向量長度,將會非常的大,所以我們就需要第三步驟 -- 特徵選取。 在這裡我們使用 scikit-learn 的 univariate 的方式來做特徵選取。因為 CountVectorizer 會傳回每一段字詞在句子裡的數目,所以在建立 feature_column 可以當作 numeric_column 來處理,同樣的我們也需要對 CountVectorizer 傳回的 sparse 矩陣,先轉為 dense array 再加上 1,對計數為零的元素做 Laplace smooth(加一)後再做正規化。
from sklearn.feature_selection import (GenericUnivariateSelect, mutual_info_classif)
selector = GenericUnivariateSelect(score_func=mutual_info_classif, mode='k_best', param=200)
select_ = selector.fit_transform(vectorized_, data_frame.loc[train_set, 'Survived'])
features['PersonInfo'] = (select_.toarray() + 1).astype(np.float32)
feature_columns.append(tf.feature_column.numeric_column(
'PersonInfo', shape=(200,), normalizer_fn=lambda x: x/tf.reduce_sum(x)))
既然已經有了 validation set 來做模型的評估,我們現在再重新訓練一次模型,並輸出評估的預測正確率,來看看我們的模型 performance 如何?
features = dict(data_frame.loc[train_set, FEATURES])
labels = features.pop('Survived')
train_ds = train_input(features, labels)
learning_rate = 0.01
gd_optimizer = tf.train.GradientDescentOptimizer(learning_rate=learning_rate)
classifier = tf.estimator.LinearClassifier(
optimizer=gd_optimizer,
feature_columns=feature_columns
)
classifier = classifier.train(input_fn=partial(train_input, features, labels),
max_steps=10)
test_features = dict(data_frame.loc[valid_set, FEATURES])
test_labels = test_features.pop('Survived')
test_text = data_frame.loc[valid_set, 'Name'].str.cat(data_frame.loc[valid_set, 'Ticket']).values
classifier.evaluate(input_fn=partial(eval_input, test_features, test_labels, test_text, vectorizer, selector)
)
# output
# {'accuracy': 0.6666667,
# 'accuracy_baseline': 0.6666666,
# 'auc': 0.5277778,
# 'auc_precision_recall': 0.5448412,
# 'average_loss': 0.61401296,
# 'label/mean': 0.33333334,
# 'loss': 5.526117,
# 'precision': 0.0,
# 'prediction/mean': 0.37537125,
# 'recall': 0.0,
# 'global_step': 10}
在下一篇中,我們將會對最佳化演算法做更詳細介紹。